From c9e8a24e93296bc61ff6c040a1230a5c76bcaaab Mon Sep 17 00:00:00 2001 From: Kai Hirota <34954529+kaihirota@users.noreply.github.com> Date: Mon, 14 Oct 2024 18:20:17 +1100 Subject: [PATCH] create immutable X sample game --- .../Shared/Scripts/UI/LevelCompleteScreen.cs | 91 +------- Assets/Shared/Scripts/UI/MainMenu.cs | 25 +-- Assets/Shared/Scripts/UI/MintScreen.cs | 132 ++++-------- Assets/Shared/Scripts/UI/SetupWalletScreen.cs | 16 +- .../Shared/Scripts/UI/UnlockedSkinScreen.cs | 84 ++++++-- 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, 372 insertions(+), 349 deletions(-) diff --git a/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs b/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs index 28d0d2220..478250c3e 100644 --- a/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs +++ b/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs @@ -6,9 +6,6 @@ using System; using System.Collections.Generic; using Immutable.Passport; -using Cysharp.Threading.Tasks; -using System.Numerics; -using System.Net.Http; namespace HyperCasual.Runner { @@ -106,7 +103,7 @@ public int CoinCount } } - public async void OnEnable() + public void OnEnable() { // Set listener to 'Next' button m_NextButton.RemoveListener(OnNextButtonClicked); @@ -120,79 +117,10 @@ public async void OnEnable() m_TryAgainButton.RemoveListener(OnTryAgainButtonClicked); m_TryAgainButton.AddListener(OnTryAgainButtonClicked); - ShowError(false); - ShowLoading(false); - - // If player is logged into Passport mint coins to player - if (SaveManager.Instance.IsLoggedIn) - { - // Mint collected coins to player - await MintCoins(); - } - else - { - // 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); - } - } - - /// - /// Mints collected coins (i.e. Immutable Runner Token) to the player's wallet - /// - private async UniTask MintCoins() - { - // This function is similar to MintCoins() in MintScreen.cs. Consider refactoring duplicate code in production. - Debug.Log("Minting coins..."); - bool success = false; - - // Show loading - ShowLoading(true); - ShowNextButton(false); - ShowError(false); - - try - { - // Don't mint any coins if player did not collect any - if (m_CoinCount == 0) - { - success = true; - } - else - { - // Get the player's wallet address to mint the coins to - List accounts = await Passport.Instance.ZkEvmRequestAccounts(); - string address = accounts[0]; - if (address != null) - { - // Calculate the quantity to mint - // Need to take into account Immutable Runner Token decimal value i.e. 18 - BigInteger quantity = BigInteger.Multiply(new BigInteger(m_CoinCount), BigInteger.Pow(10, 18)); - Debug.Log($"Quantity: {quantity}"); - var nvc = new List> - { - // Set 'to' to the player's wallet address - new KeyValuePair("to", address), - // Set 'quanity' to the number of coins collected - new KeyValuePair("quantity", quantity.ToString()) - }; - using var client = new HttpClient(); - string url = $"http://localhost:3000/mint/token"; // Endpoint to mint token - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(nvc) }; - using var res = await client.SendAsync(req); - success = res.IsSuccessStatusCode; - } - } - } - catch (Exception ex) - { - Debug.Log($"Failed to mint coins: {ex.Message}"); - } - - ShowLoading(false); - ShowNextButton(success); - ShowError(!success); + // 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 async void OnContinueWithPassportButtonClicked() @@ -204,11 +132,7 @@ private async void OnContinueWithPassportButtonClicked() ShowLoading(true); // Log into Passport -#if (UNITY_ANDROID && !UNITY_EDITOR_WIN) || (UNITY_IPHONE && !UNITY_EDITOR_WIN) || UNITY_STANDALONE_OSX - await Passport.Instance.LoginPKCE(); -#else await Passport.Instance.Login(); -#endif // Successfully logged in // Save a persistent flag in the game that the player is logged in @@ -228,9 +152,8 @@ private async void OnContinueWithPassportButtonClicked() } } - private async void OnTryAgainButtonClicked() + private void OnTryAgainButtonClicked() { - await MintCoins(); } private void OnNextButtonClicked() @@ -287,4 +210,4 @@ void DisplayCoins(int count) } } } -} \ No newline at end of file +} diff --git a/Assets/Shared/Scripts/UI/MainMenu.cs b/Assets/Shared/Scripts/UI/MainMenu.cs index 052370c66..379379df3 100644 --- a/Assets/Shared/Scripts/UI/MainMenu.cs +++ b/Assets/Shared/Scripts/UI/MainMenu.cs @@ -38,16 +38,9 @@ async void OnEnable() m_LogoutButton.AddListener(OnLogoutButtonClick); // Initialise Passport - string clientId = "YOUR_IMMUTABLE_CLIENT_ID"; + string clientId = ""; string environment = Immutable.Passport.Model.Environment.SANDBOX; - string redirectUri = null; - string logoutUri = null; - -#if (UNITY_ANDROID && !UNITY_EDITOR_WIN) || (UNITY_IPHONE && !UNITY_EDITOR_WIN) || UNITY_STANDALONE_OSX - redirectUri = "immutablerunner://callback"; - logoutUri = "immutablerunner://logout"; -#endif - passport = await Passport.Init(clientId, environment, redirectUri, logoutUri); + 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()) @@ -56,20 +49,20 @@ async void OnEnable() 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.ConnectEvm(); - await Passport.Instance.ZkEvmRequestAccounts(); + await Passport.Instance.ConnectImx(); } - } - else - { + } 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); } @@ -95,11 +88,7 @@ async void OnLogoutButtonClick() ShowLoading(true); // Logout -#if (UNITY_ANDROID && !UNITY_EDITOR_WIN) || (UNITY_IPHONE && !UNITY_EDITOR_WIN) || UNITY_STANDALONE_OSX - await passport.LogoutPKCE(); -#else await passport.Logout(); -#endif // 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 ab2aacb66..8e66c8197 100644 --- a/Assets/Shared/Scripts/UI/MintScreen.cs +++ b/Assets/Shared/Scripts/UI/MintScreen.cs @@ -4,11 +4,19 @@ using UnityEngine.UI; using System; using System.Collections.Generic; -using System.Threading; using System.Net.Http; -using Immutable.Passport; +using System.Threading; using Cysharp.Threading.Tasks; -using System.Numerics; +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 { @@ -39,7 +47,6 @@ public class MintScreen : View // If there's an error minting, these values will be used when the player clicks the "Try again" button private bool mintedFox = false; - private bool mintedCoins = false; public void OnEnable() { @@ -57,7 +64,6 @@ public void OnEnable() // Reset values mintedFox = false; - mintedCoins = false; Mint(); } @@ -74,33 +80,24 @@ private async void Mint() // Mint fox if not minted yet if (!mintedFox) { - mintedFox = await MintFox(); - } - // Mint coins if not minted yet - if (!mintedCoins) - { - mintedCoins = await MintCoins(); - } + MintResult mintResult = await MintFox(); - // Show minted message if minted both fox and coins successfully - if (mintedFox && mintedCoins) - { + // Show minted message if minted fox successfully ShowMintedMessage(); } - ShowLoading(false); - // Show error if failed to mint fox or coins - ShowError(!mintedFox || !mintedCoins); - // Show next button is minted both fox and coins successfully - ShowNextButton(mintedFox && mintedCoins); } 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); } /// @@ -108,15 +105,15 @@ private async void Mint() /// private async UniTask GetWalletAddress() { - List accounts = await Passport.Instance.ZkEvmRequestAccounts(); - return accounts[0]; // Get the first wallet address + 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() + private async UniTask MintFox() { Debug.Log("Minting fox..."); try @@ -134,60 +131,26 @@ private async UniTask MintFox() 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); - return res.IsSuccessStatusCode; - } - return false; - } - catch (Exception ex) - { - Debug.Log($"Failed to mint fox: {ex.Message}"); - return false; - } - } + // Parse JSON and extract token_id + string content = await res.Content.ReadAsStringAsync(); + Debug.Log($"Mint fox response: {content}"); - /// - /// Mints collected coins (i.e. Immutable Runner Token) to the player's wallet - /// - /// True if minted coins successfully to player's wallet. Otherwise, false. - private async UniTask MintCoins() - { - Debug.Log("Minting coins..."); - try - { - int coinsCollected = GetNumCoinsCollected(); // Get number of coins collected - if (coinsCollected == 0) // Don't mint any coins if player did not collect any - { - return true; - } + MintResult mintResult = JsonUtility.FromJson(content); + Debug.Log($"Minted fox with token_id: {mintResult.token_id}"); - string address = await GetWalletAddress(); // Get the player's wallet address to mint the coins to - if (address != null) - { - // Calculate the quantity to mint - // Need to take into account Immutable Runner Token decimal value i.e. 18 - BigInteger quantity = BigInteger.Multiply(new BigInteger(coinsCollected), BigInteger.Pow(10, 18)); - Debug.Log($"Quantity: {quantity}"); - var nvc = new List> - { - // Set 'to' to the player's wallet address - new KeyValuePair("to", address), - // Set 'quanity' to the number of coins collected - new KeyValuePair("quantity", quantity.ToString()) - }; - using var client = new HttpClient(); - string url = $"http://localhost:3000/mint/token"; // Endpoint to mint token - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(nvc) }; - using var res = await client.SendAsync(req); - return res.IsSuccessStatusCode; + mintedFox = res.IsSuccessStatusCode; + return mintResult; } - return false; + mintedFox = false; + return null; } catch (Exception ex) { - Debug.Log($"Failed to mint coins: {ex.Message}"); - return false; + Debug.Log($"Failed to mint fox: {ex.Message}"); + mintedFox = false; + return null; } } @@ -221,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!"; } /// @@ -245,15 +199,7 @@ 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 async void OnWalletClicked() @@ -261,7 +207,7 @@ 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://explorer.testnet.immutable.com/address/{address}?tab=tokens"); + 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 b8722cc51..58780db0f 100644 --- a/Assets/Shared/Scripts/UI/SetupWalletScreen.cs +++ b/Assets/Shared/Scripts/UI/SetupWalletScreen.cs @@ -55,9 +55,15 @@ private async void SetupWallet() ShowSuccess(false); // Set up provider - await Passport.Instance.ConnectEvm(); - // Set up wallet (includes creating a wallet for new players) - await Passport.Instance.ZkEvmRequestAccounts(); + 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); @@ -66,8 +72,8 @@ private async void SetupWallet() } catch (Exception ex) { - // Failed to set up wallet, let the player try again - Debug.Log($"Failed to set up wallet: {ex.Message}"); + // Failed to create wallet, let the player try again + Debug.Log($"Failed to create wallet: {ex.Message}"); ShowLoading(false); ShowError(true); ShowSuccess(false); diff --git a/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs b/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs index cab7aec47..059e2e25b 100644 --- a/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs +++ b/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs @@ -5,10 +5,28 @@ 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 { /// @@ -47,7 +65,7 @@ public CraftSkinState? CraftState get => m_CraftState; set { - CraftState = value; + m_CraftState = value; switch (m_CraftState) { case CraftSkinState.Crafting: @@ -115,31 +133,59 @@ public async void OnEnable() private async void Craft() { - CraftState = CraftSkinState.Crafting; + try { + m_CraftState = CraftSkinState.Crafting; - // Burn tokens and mint a new skin i.e. crafting a skin - TransactionReceiptResponse response = await Passport.Instance.ZkEvmSendTransactionWithConfirmation(new TransactionRequest() - { - to = "YOUR_IMMUTABLE_RUNNER_TOKEN_CONTRACT_ADDRESS", // Immutable Runner Token contract address - data = "0x1e957f1e", // Call craftSkin() in the contract - value = "0" - }); - Debug.Log($"Craft transaction hash: {response.transactionHash}"); + // burn + Asset[] assets = await GetAssets(); + if (assets.Length == 0) + { + Debug.Log("No assets to burn"); + m_CraftState = CraftSkinState.Failed; + return; + } - if (response.status != "1") - { + 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})"); + + 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) + { + CollectSkin(); + } + } catch (Exception ex) { + Debug.Log($"Failed to craft skin: {ex.Message}"); m_CraftState = CraftSkinState.Failed; - return; } + } - CraftState = CraftSkinState.Crafted; + private async UniTask GetAssets() + { + const string collection = "0xcf77af96b269169f149b3c23230e103bda67fd0c"; + string address = await Passport.Instance.GetAddress(); + Debug.Log($"Wallet address: {address}"); - // 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 (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() @@ -150,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,