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..62e1d5251 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 = "MnIdiF95fTw4vsyGJGHGdbjxnKZV5lfG";
+ 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/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"
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,